#!/usr/bin/perl -w
# $Id: slack 207 2008-10-06 02:34:07Z sundell $
# vim:sw=2
# vim600:fdm=marker
# Copyright (C) 2004-2008 Alan Sundell <alan@sundell.net>
# All Rights Reserved.  This program comes with ABSOLUTELY NO WARRANTY.
# See the file COPYING for details.

# This script is in charge of copying files from the (possibly remote)
# master directory to a local cache, using rsync

require 5.006;
use warnings FATAL => qw(all);
use strict;
use sigtrap qw(die untrapped normal-signals
               stack-trace any error-signals);

use File::Path;
use File::Find;
use POSIX; # for strftime
use YAML ();  # FIXME: add dependency for this

use constant LIBEXEC_DIR => '/usr/lib/slack';
use constant LIB_DIR => '/usr/lib/slack';
use lib LIB_DIR;
use Slack;

sub hashprop($$;$);
sub match_conditions($);
sub run_tasks(@);
sub run_backend($@);

(my $PROG = $0) =~ s#.*/##;

my @roles;

########################################
# Environment
# Helpful prefix to die messages
$SIG{__DIE__} = sub { die "FATAL[$PROG]: @_"; };
# Set a reasonable umask
umask 077;
# Get out of wherever (possibly NFS-mounted) we were
chdir("/")
  or die "Could not chdir /: $!";
# Autoflush on STDERR
select((select(STDERR), $|=1)[0]);

########################################
# Config and option parsing {{{
my $usage = Slack::default_usage("$PROG [options] [<role>...]");
$usage .= <<EOF;

  --preview MODE
      Do a diff of scripts and files before running them.
      MODE can be one of 'simple' or 'prompt'.

  --no-files
      Don't install any files in ROOT, but tell rsync to print what
      it would do.

  --no-scripts
      Don't run scripts.

  --no-sync
      Skip the slack-sync step.  (useful if you're pushing stuff into
        the CACHE outside of slack)

  --role-list
      Role list for slack-getroles

  --libexec-dir DIR
      Look for backend scripts in this directory.

  --diff PROG
      Use this diff program for previews

  --sleep TIME
      Randomly sleep between 1 and TIME seconds before starting
      operations
EOF

# Options
my %opt = (
  scripts => 1,
  files => 1,
  sync => 1,
  backup => 1,
  'libexec-dir' => LIBEXEC_DIR,
);
# So we can distinguish stuff on the command line from config file stuff
my %command_line_opt = ();
Slack::get_options(
  opthash => \%opt,
  command_line_options => [
    'preview=s',
    'role-list=s',
    'scripts!',
    'files!',
    'sync!',
    'libexec-dir=s',
    'diff=s',
    'sleep=i',
  ],
  required_options => [ qw(source cache stage root) ],
  restricted_options => {
      preview => [ qw(simple prompt) ],
  },
  command_line_hash => \%command_line_opt,
  usage => $usage,
);

# Special option --dry-run is equivalent to --no-scripts --no-files
# FIXME: This should actually turn on --dry-run in these backends
if ($opt{'dry-run'}) {
  $opt{'scripts'} = 0;
  $opt{'files'} = 0;
}

# Figure out a place to put backups.  We set the time-specific dir
# here so that a single run will end up in a single dir. 
# FIXME: backups from multiple roles clobber each other if we have
#   a single dir (not that people should have the same file in multiple
#   roles, but...)
if ($opt{backup} and $opt{'backup-dir'}) {
  $opt{'backup-dir'} = 
      $opt{'backup-dir'}.
      "/".
      strftime('%F-%T', localtime(time))
    ;
}
# }}}

# Random sleep, helpful when called from cron.
if ($opt{sleep}) {
  my $secs = int(rand($opt{sleep})) + 1;
  $opt{verbose} and print STDERR "$PROG: sleep $secs\n";
  sleep($secs);
}

# FIXME: put this somewhere more intelligent
my ($tasks) = YAML::LoadFile($opt{'libexec-dir'}.'/backends.conf');

# Get a list of roles to install
if (@ARGV) {
  @roles = @ARGV;
} else {
  # Use backends that return roles
  for my $task (@{$tasks}) {
    next unless (hashprop($task, 'returns', 'roles'));
    my $results = run_backend($task);
    push @roles, split(/\s+/, $results->{output});
  }
}
Slack::assert_valid_role_names(@roles);

$opt{verbose} and print STDERR "$PROG: installing roles: @roles\n";

exit run_tasks(@$tasks);

########################################
# Subroutines

# Perl makes this so annoying I put it in a subroutine
sub hashprop($$;$) {
  my ($hash, $key, $value) = @_;
  return unless (defined $hash and defined $key);
  return unless (defined $hash->{$key});
  if (not defined $value) {
    return 1;
  }
  return ($hash->{$key} eq $value);
}

sub run_tasks (@) {
  my (@tasks) = @_;

  my ($exit) = 0;

  TASK: while (my $task = shift @tasks) {
    if (hashprop($task, 'returns')) {
      next;
    }

    if (hashprop($task, 'per_role', 'yes')) {
      # We run consecutive per-role tasks and in a separate, per-role
      # loop.
      my @per_role_tasks = ($task);
      while (@tasks and hashprop($tasks[0], 'per_role', 'yes')) {
        push @per_role_tasks, shift @tasks;
      }

      my $final = 0;
      ROLE: for my $role (@roles) {
        for my $role_task (@per_role_tasks) {
          if (match_conditions($role_task)) {
            my $results = run_backend($role_task, $role);
            $exit = 1 if not $results->{success};

            if (hashprop($role_task, 'final', 'yes')) {
              $final = 1;
              next ROLE;
            }
          }
        }
      }
      last TASK if $final;
    } elsif (match_conditions($task)) {
      # multiple-role-capable steps are easy -- just run them
      my $results = run_backend($task, @roles);
      $exit = 1 if not $results->{success};

      if (hashprop($task, 'final', 'yes')) {
        last TASK;
      }
    }
  }
  return $exit;
}

sub match_conditions($) {
  my ($task) = @_;
  if (defined $task->{require_options}) {
    for my $option (@{$task->{require_options}}) {
      my ($option, $value) = split(/=/, $option, 2);

      if ((not $opt{$option}) or
          (defined $value and $opt{$option} ne $value)) {
        if ($opt{verbose} > 2) {
          print STDERR "$PROG: Skipping " . $task->{label} .
            " because required option $option not set\n";
        }

        # FIXME: In some cases, we actually want to run the command
        # with --dry-run
        return undef;
      }
    }
  }
  return 1;
}

sub run_backend ($@) {
  my ($task, @roles) = @_;
  my @command;


  $opt{verbose} and print STDERR "$PROG: $task->{label} @roles\n";

  ### Build up the command

  # If we weren't given an explicit path, prepend the libexec dir
  {
    my $command = $task->{command};
    unless ($command =~ m#^/#) {
      $command = $opt{'libexec-dir'} . '/' . $command;
    }
    @command = ($command);
  }

  # Build up the args for this thing...
  if (defined $task->{extra_args}) {
    push @command, @{$task->{extra_args}};
  }

  # propagate verbosity - 1 to all backends
  if (defined $command_line_opt{'verbose'} and
      $command_line_opt{'verbose'} > 1) {
    push @command,
        ('--verbose') x ($command_line_opt{'verbose'} - 1);
  }

  # propagate these flags to all the backends if they were specified
  # on the command line.
  for my $option (qw(config root cache stage source hostname rsh)) {
    if ($command_line_opt{$option}) {
      push @command, "--$option=$command_line_opt{$option}";
    }
  }

  if (defined $task->{extra_options}) {
    for my $option (@{$task->{extra_options}}) {
      if (defined $opt{$option}) {
        # FIXME: Stupid hack to deal with boolean options
        if ($opt{$option} eq '0') {
          push @command, "--no-$option";
        } elsif ($opt{$option} eq '1') {
          push @command, "--$option";
        } else {
          push @command, "--$option=$opt{$option}";
        }
      }
    }
  }

  if (not defined $task->{role_args} or $task->{role_args} eq 'yes') {
    push @command, @roles;
  }

  ### Execute the command

  ($opt{verbose} > 2) and print STDERR "$PROG: Calling '@command'\n";

  # Hash containing:
  #     success => 1 if we succeeded, undef otherwise
  #     exit    => exit code of command
  #     output  => STDOUT from command
  # We have both exit and success because it's possible to succeed on a bad
  # exit if we're told to continue by a prompt.
  my $results = {};

  if (defined $task->{returns}) {
    # Get output of command, but use open('-|') and exec() instead of `` so we
    # don't go through the shell.
    my ($child_pid, $child_fh);
    if ($child_pid = open($child_fh, '-|')) {
      # Parent
    } elsif (defined $child_pid) {
      # Child
      exec(@command);
      die "Could not exec '@command': $!\n";
    } else {
      die "Could not fork to run '@command': $!\n";
    }
    $results->{output} = join('', <$child_fh>);
    if (close($child_fh)) {
      $results->{success} = 1;
      $results->{exit} = 0;
    }
  } else {
    if (system(@command) == 0) {
      $results->{success} = 1;
      $results->{exit} = 0;
    }
  }

  ### Handle different exit statuses depending on $task->{conditional}

  if (not $results->{success}) {
    # This will croak if the command got a signal or crashed
    $results->{exit} = Slack::get_system_exit(@command);

    if (defined $task->{prompt_exit_codes} and
        grep { $_ == $results->{exit} } @{$task->{prompt_exit_codes}}) {
      exit 1 unless Slack::prompt("Continue? [yN] ") eq 'y';
      $results->{success} = 1;
    } elsif (defined $task->{pass_exit_codes} and
        grep { $_ == $results->{exit} } @{$task->{pass_exit_codes}}) {
      # Do nothing.
    } else {
      Slack::check_system_exit(@command);
    }
  }

  return $results;
}
